今天想要介紹如何在合約中優化 gas fee。因為在以太坊中的 gas fee 價錢實在是太高昂了,而 gas fee 的成本主要與合約設計本身有很大的關係。降低 gas fee 除了可節省項目方(開發者)付出的成本,也可以降低消費者(mint 者)所需要花費的金額。
但其實降低成本時,很多時候也會產生另一層面的問題,例如將 variable 的大小改動時,可能要注意 overflow 的情況,這些情況對於開發者或消費者而言都是不樂見的,因此在使用時也需要非常小心。
會想要寫入鐵人是因為我自己不管是在做專案或是一般測試的時候,常常忽略掉一些小地方,導致最後的 gas fee 與原本預測的相差甚遠,因此想透過寫入文章的方式提醒我未來需要考慮的更周全。
solidity 在宣告變數的時候,同時也需要宣告其型別,因此在使用變數前可以思考這個變數會使用到多少的空間,有沒有可能將其大小縮減。
在 solidity 中 uint
便是宣告一個 uint256
的變數。其中 256 的意義是可以存取多少 bit,uint256
的範圍就可以從 0 ~ 2^256 - 1
、而像uint128
的範圍就是 0 ~ 2^128
,以此類推。
用 solidity 內建的 type 查詢可以看到:
function getMax() public pure returns(uint256, uint128, uint64, uint32, uint16, uint8) {
return (
type(uint256).max,
type(uint128).max,
type(uint64).max,
type(uint32).max,
type(uint16).max,
type(uint8).max
);
}
>> uint256: 115792089237316195423570985008687907853269984665640564039457584007913129639935
>> uint128: 340282366920938463463374607431768211455
>> uint64: 18446744073709551615
>> uint32: 4294967295
>> uint16: 65535
>> uint8: 255
因此在宣告變數的時候可以先想想「這個函數需要做什麼?」、或是「他有可能遇到什麼樣的狀況?」等情況去發想來做設計。
例如今天要宣告一個 TOTAL_SUPPLY
的全域變數,而你實際的 NFT 供給量只有 1000 個,這時你可以用 uint16
而不是直接宣告一個 uint256
來當作它的型別。
雖然平常時可以將 uint 設置成自己想要的大小,但是還是會有例外的!(其實也不算是例外,只是要了解設置背後的意義是什麼?)
在寫 BAKAJOHN 還有其他 NFT 相關智能合約時,我常常遇到一個問題 -- 「為什麼 tokenId 需要用一個 uint256
來存?」。原因是,我明明就最多只有 total_supply
個 NFT 被 mint,但為什麼需要付出這麼多額外的 storage 來存這個變數,豈不是浪費了嗎?
查詢 StackOverflow 查到了一個相關的問題解答了我。
其實 tokenId 不需要是連續整數阿!
The choice of
uint256
allows a wide variety of applications because UUIDs and sha3 hashes are directly convertible touint256
.
在一些使用情境,項目方可以將 tokenId 設置成一個 hash 值,在這些情況,因為 hash 可以直接存入一個 uint256 裡面,因此會更適合拿來使用。
但是這樣的 tokenId 可能便需要配合 metadata 的檔名,才能讓 Opensea 取得。
另外,配合著 variable 的內容,不得不提到一個在程式中重要的機制 -- LOOP。
在很多時候我們都需要使用到 for loop
,但是 for loop
可能會需要花費較多的 gas fee 來做到同樣的事情。
先前在 【DAY18】 時就提到嘟嘟房使用的 whitelist 是用個 array 來包起來。這時他們要去存取一個 address 最多要花 O(n)
的時間,也就是用 for loop
從 0 跑到 n-1 的狀況。
因此這時則需要使用 mapping
會是個較好的選擇。
在很多時候會使用 mapping
作為查詢的工具,而 array
則多會使用在儲存一些其他的變數,像是在 Dynamic NFT 中使用的 string URI[3] = ["CID1", "CID2", "CID3"]
等。
我們都知道在鏈上儲存一個東西,在 EVM 中會呼叫 SSTORE
這個 OPCODE
,在這邊可以看到:
儲存一個 variable(256 bits) 最多會花費 20000 gas,而若是儲存太多資料(例:整棵 Merkle Tree)在鏈上將會耗費超大量的 gas fee,且運作的時候將會非常笨重。
因此在鏈上儲存一些東西時,會使用其他的選擇來避免直接儲存在鏈上。
前面有提到的 metadata 多會儲存在 IPFS 中,這方面我就不多贅述(見 DAY7)。
除了 NFT 的 metadata 以外,其實也有很多東西適合儲存在 IPFS 中。
例如在 9 月中我參加了一個黑客松,我們的作品是一個 Community 的 DAO,這個作品主要是可以驗證大家擁有的 NFT,驗證過後可以讓合格的使用者提出提案(就像是 EIP 一樣)。
而我們在提案的時候需要使用者提供:
事實上,我們在比賽途中有提到是否可以使用 IPFS 當作儲存方法,但最後因為時間緣故沒有實做出來。
當時的想法是
前端 --(information)-> web3.storage(IPFS) -> Write in Smart Contract
在 IPFS 上儲存後會產生一個 CID,這個 CID 是個固定長度的 string
(因為是透過 hash() function
產生),而透過這個 CID 便可以用各式各樣的 gateway 來 fetch 這些資料。
在這個使用情境下,透過 IPFS 將 CID 儲存在鏈上會遠比直接將資料儲存在鏈上的方式還要好;但這也可能產生另一層面的問題 -- 需要做出其他額外的方式來 fetch API 等,這方面就不再多贅述了。
前幾天介紹了 Merkle Proof 的使用情景與他的驗證方式,因此我們可以了解在鏈上維持一棵 Merkle Tree 似乎是個不明智的選擇。
在鏈上維持一棵樹的代價有:
儲存非常多的資料: 我們需要將一棵樹用一個 array 儲存,再利用 hash()
將其一步步的雜湊上去,同時也需要用其他的 array 來儲存這些 hash 值。
insertion or deletion: 由於不是使用 mapping 來存,無法直接用 mapping(address => hash(address))
的方式來存取,因此也只能用 iteration 的方式來找到 element 再插入或刪除。
由於以上因素,實作的時候通常不會將 Merkle Tree 放在鏈上,而是會在鏈下(off-chain)進行各種運算,由於是鏈下運算,同時連 proof 也可以一起產出。
最後,在鏈上唯一需要儲存的東西剩下 merkle tree 的 root,並配合鏈下產生的 proof 與 leaf 一同做驗證的功能。
以上是我看到別人的文章並融合了自身開發經驗所做出的一些心得,但不代表完全正確,像是我也看到一篇文章提出 variable ordering 並不值得花太多的時間與精力,因為節省的效果很低(約 $1~2 美左右)。但我認為對於一個學習者而言,更嚴謹的對待自己寫的每一行合約/ code 才能讓自己成長,並在未來遇到類似問題(like overflow)的時候才能夠清楚的理解是哪裡出了錯,所以多用點心還是不虧的吧!
明天會分享關於盲盒與 mint 相關的內容!